feat(web): Profiles tab -- browse, search, create, edit, clone, delet… - #692
feat(web): Profiles tab -- browse, search, create, edit, clone, delet…#692sujoydc wants to merge 5 commits into
Conversation
haofeif
left a comment
There was a problem hiding this comment.
Thanks for the very thorough write-up — the self-review section made this much faster to audit, and every hardening claim in it held up under execution (details below).
Reviewed at 0948572a (merge-base b6a0520b). Web-only: no Python file changes.
Author claims I reproduced
| Claim | Result |
|---|---|
| 63 new UI tests | Exact. merge-base 177 passed, PR 240 passed -> +63 |
| Full web suite 240/240 | Exact. Test Files 16 passed, Tests 240 passed |
tsc clean, production build clean |
Confirmed. npm run build exit 0 |
| No pre-existing regressions | Confirmed. merge-base 177/177 green; 0 tests regressed |
rewriteFrontmatterName handles $-patterns |
Confirmed for $&, $', $`, $1, $$, both in the typed name and pre-existing in the frontmatter |
| CRLF documents rewrite rather than silently no-op | Confirmed |
Editor loads unresolved /source, never the resolved parse |
Confirmed, and genuinely pinned (mutation below) |
| Truncation contract rendered exactly | Confirmed against live validator output |
| Search order never re-sorted | Confirmed — the fixture is genuinely discriminating |
| Backdrop inert while saving | Confirmed in both modals |
ConfirmModal.confirmationText additive |
Confirmed — gated on !== undefined; no existing caller passes it |
Truncation contract, end-to-end
Rather than trust the copied constant, I drove the real validator to truncation:
300 non-string capabilities: total=100 markers=1 marker_idx=[99] last_sev=error
last_message='Additional validation findings omitted.'
EXACT-MATCH-UI-CONSTANT=True
100 including the marker, exactly one marker, last position, error severity because errors were dropped, and byte-identical to ValidationFindings.OMISSION_MESSAGE. isOmissionMarker requires index === all.length - 1, so the marker text in a non-final position does render as an ordinary finding, as documented.
The secret-leak path is really pinned
I mutated getProfileSource to drop /source (so the editor would read the resolved profile):
Tests 9 failed | 6 passed (15)
Nine editor tests go red. That behaviour is enforced, not just asserted in prose.
Things I suspected and cleared
- Portaled
CustomSelectmenu breaking existing callers.z-[80]clears thez-[60]maximum elsewhere, and the two backdrop closers (AgentPanel:518,FlowsPanel:300) are element-levelonClicks, not document-level handlers, so a click on the portaled menu never reaches them. The pre-existingmemory-graphtest opens the menu and clicks an option, so this is covered by tests that predate the PR. - Mixed line endings from a CRLF clone.
rewriteFrontmatterNamedoes emit a mixed-ending document (---\n...\r\n...---\r\n). I fed it to the real backend parser and it round-trips cleanly (meta={'name': 'newname', 'description': 'd'}), as does${VAR}inside a JSON-valued field. Not a defect — noting it only so it doesn't get re-litigated. - Route ordering. All 14 URLs match
api/main.py, and every literal path is declared before/agents/profiles/{name}. deleteProfileagainst a 204.fetchJSON's empty-body branch already covers it.
What blocks
Two defects, same root cause: a debounced effect whose early return does not invalidate the in-flight request token. The monotonic tokens are correct for the case they were written for (a newer request superseding an older one) but not for "the reason to want this response disappeared".
The first one silently persists the wrong document, so I'm marking this changes-requested — everything else here is in good shape, and both fixes are one-liners I verified.
Both fixes together keep 240 passed and tsc/build clean.
| // Debounced live preview: one render request per quiet burst of config edits. | ||
| useEffect(() => { | ||
| if (previewTimer.current) clearTimeout(previewTimer.current) | ||
| if (!template || !templateSchema) return |
There was a problem hiding this comment.
[P1] A stale template preview can be persisted under a different template
In template mode the create POST body is the preview state (ProfileCreateModal.tsx:471), and Create is gated on preview !== null && !previewLoading (:462) precisely so a mid-debounce render can't be persisted. That gate can be defeated.
Selecting a different template runs setTemplateSchema(null) synchronously (:316). The preview effect then re-runs and hits this early return — which does not bump previewSeq and does not clear preview. When the previous template's in-flight render lands, seq === previewSeq.current still holds, so it calls setPreview(oldContent) and setPreviewLoading(false).
Reproduced (template A's preview released after switching to template B, B's schema still loading):
PROBE selected template shows: aws/stepfunction
PROBE preview pane present: false | contains TEMPLATE-A-BODY: undefined
PROBE create button disabled: false
PROBE POSTed body contains TEMPLATE-A-BODY: true
This is silent rather than merely wrong-looking: the whole Live preview block is gated on (templateSchema || previewError) (:593), so during this window the user sees only "Loading template schema…" (:570) — nothing on screen shows template A's content — while Create is enabled and armed with it. Clicking it writes a profile whose body came from the template the user just navigated away from, under the name they chose, with no error.
Fix — invalidate the token and drop the orphaned render:
if (previewTimer.current) clearTimeout(previewTimer.current)
if (!template || !templateSchema) {
// The reason to want the in-flight render just disappeared: discard it
// so it cannot land as this template's preview (and be persisted).
previewSeq.current++
setPreview(null)
return
}Verified: PROBE create button disabled: true, no stale POST, and the full suite stays at 240 passed. (Leaving previewLoading true here is correct — the next schema load re-arms it.)
| useEffect(() => { | ||
| if (debounceRef.current) clearTimeout(debounceRef.current) | ||
| const q = query.trim() | ||
| if (q === '') { |
There was a problem hiding this comment.
[P2] Clearing the search box restores stale results once the in-flight response lands
Same shape as the P1, without the write. Clearing the box takes this early return after setResults(null), but never bumps searchSeq, so an already-dispatched search still satisfies seq === searchSeq.current at :283 and calls setResults(r). Since rows = results ?? catalog (:299), the list drops back to the filtered set.
Reproduced (response released after the box is cleared):
PROBE search requests in flight: 1
PROBE rows after clear (before response lands): 6
PROBE searchbox value: ""
PROBE rows after stale response lands: 1 ["mid-agentkir"]
The user is left looking at a filtered list with an empty search box and no indication why — including the duplicated_in shadowing warnings that only the catalog rows carry. It is recoverable (type a character and clear again, with nothing in flight), which is why this is a P2 and not a P1.
Fix:
if (q === '') {
searchSeq.current++
setResults(null)
setSearchError(null)
setSearching(false)
return
}Verified: the probe then reports all 6 catalog rows, and the full suite stays at 240 passed.
| } | ||
|
|
||
| /** Extract the frontmatter `name:` value from a rendered document, if any. */ | ||
| export function extractFrontmatterName(content: string): string | null { |
There was a problem hiding this comment.
[nit] extractFrontmatterName can match a name: line in the markdown body
[\s\S]*? isn't bounded to the end of the frontmatter block, so when the frontmatter has no name: the scan continues into the document body:
EXTRACT no-fm-name -> "decoy" // '---\ndescription: d\n---\nbody\nname: decoy\n'
EXTRACT normal -> "real"
EXTRACT quoted -> "real"
EXTRACT crlf -> "real"
Impact today is limited to pre-filling the profile-name box (:343, only when !nameTouched.current), and every shipped template carries a frontmatter name, so I don't think it's reachable — hence nit. Bounding the scan to the block would close it, e.g. match the frontmatter with /^---\r?\n([\s\S]*?)\r?\n---/ first and search name: within that captured group, which is what rewriteFrontmatterName already does correctly.
gutosantos82
left a comment
There was a problem hiding this comment.
PR Review: #692 — feat(web): Profiles tab — browse, search, create, edit, clone, delete profiles
Summary
Adds the Profiles tab to the CAO Web UI (browse/search/create/edit/clone/delete over the #575/#585 APIs) — the final piece of #510. The implementation and its 63 new tests are high quality: we independently reproduced the 240/240 suite, clean tsc/build, and every hardening claim in the PR body. However, maintainer @haofeif has an open CHANGES_REQUESTED at this exact head for a data-integrity race (stale template preview silently persisted), and our review found the root cause is systemic: previewSeq is bumped only when a request is issued, never when the preview is invalidated, so the flagged one-liner alone leaves two more paths to the same silent persistence. Recommendation: fix the token-invalidation class as a whole (all clear sites), not just the two flagged lines, and land regression tests with it.
Blocking (must fix before merge)
- [correctness][verification] web/src/components/ProfileCreateModal.tsx:286-306 (open-reset effect) and :311-314 (template-deselect branch) — 🆕 Both
setPreview(null)without bumpingpreviewSeq.current(the reset also leavespreviewTimerandpreviewLoadinguntouched). The modal stays mounted across close (openprop), so: preview in flight → close → reopen → the stale response still satisfiesseq === previewSeq.current, re-landssetPreview(staleContent)+setProfileName(extractFrontmatterName(stale)), andcanCreate(which checks onlypreview !== null && !previewLoading, nottemplate) enables Create — persisting a body from a template that isn't even selected, with the preview pane not rendered. This is the same silent-persistence class as the maintainer's P1 but a distinct trigger his line-333 fix does not cover. Fix: invalidate the token at every clear site — bumppreviewSeq.current, clearpreviewTimer, resetpreviewLoadingin the reset effect and the deselect branch (or bump the token on every preview-effect run/teardown).
Important (should fix)
- [consistency][correctness] ProfileCreateModal.tsx:309-327 — 🆕
getTemplateSchemais the one fetch in the modal with no staleness guard (no seq token, no cancelled flag, no abort) — unlike the search, preview, andProfileDetail.getProfileeffects beside it. Fast A→B template switch with out-of-order resolution leavestemplateSchema= A's schema andconfig= A's seeded defaults whiletemplate= B: the form renders A's fields under a B selection and the debounced preview renders B with A's config. Same fix norm as the blocking item: token orcancelledflag keyed on the selected template. - [tests] web/src/test/ — 🆕 The monotonic seq-guard machinery the PR headlines has zero test coverage: no test creates two overlapping in-flight requests with reordered resolution, so the
seq !== ...currentdiscard branch is never taken (all search/preview mocks resolve synchronously). The P1/P2 fixes should ship with gate-promise regression tests — (a) search: type, let the request go in flight, clear the box, then resolve → assert results stay null; (b) preview: template A render in flight, switch to B, resolve A → assert no A content in pane and Create does not POST A's body. Otherwise the one-liner fixes land undefended. - [tests] web/src/components/CustomSelect.tsx — 🆕 The shared-component rewrite (portal to
document.body, fixed positioning, flip-up, scroll/resize-to-close, portal-aware outside-click, newinvalidprop) is essentially untested — tests only pick options by testid, and theinvalidred-boundary path on a select is never asserted (the two red-boundary tests hit text/number inputs). This component backs Flows and Agents too; a regression in scroll-close/flip would be invisible to the suite. - [correctness] ProfileCreateModal.tsx (SchemaField object branch) — 🆕 Object/JSON textareas are uncontrolled (
defaultValue) and always receivevalue=undefined(object fields render only in Advanced, which passesundefinedfor object types). Collapse and reopen Advanced (or trigger the error-driven auto-expand) and the textarea remounts empty whilejsonDraftsstill holds — and persists — the typed JSON. Display/state desync: user sees an empty field, the value saves anyway; retyping clobbers the draft. Make the textarea controlled from the draft string. - [conventions] web/README.md — 🆕 The canonical frontend doc (linked from docs/web-ui.md and CODEBASE.md) enumerates every page, supporting component, and file in three places; this PR adds a page (ProfilesPanel) + three components (ProfileCreateModal, ProfileEditorModal, ValidationFindings) and updates none of them. Per the repo's documentation-maintenance rule, update all three enumerations in this PR.
- [conventions] CHANGELOG.md — 🆕 No
[Unreleased] → Addedentry for the new Profiles tab. The #510 arc's API work is already recorded in the changelog; this user-facing UI piece should be too.
Nits (optional)
- [correctness] ProfileCreateModal/ProfileEditorModal save paths — a transport error/timeout on the pre-save
validateProfile(a UX gate; the write route re-validates authoritatively) hard-blocks the save behind a generic "Validation failed". Consider letting transport failures fall through to the server-side validation while still blocking on real 400s. - [consistency] web/src/api.ts:174, 484-495 —
TemplateConfigValidation+validateTemplateConfig()are defined but never referenced anywhere in web/src (the UI usespreviewTemplateinstead). Wire it or drop it. - [consistency] web/src/api.ts:474-476 — the "category/name travels as two path segments, slash must NOT be encoded" comment sits above
getProfileSchema(fixed path, no template arg) but describesgetTemplateSchemabelow it. Move it. - [tests] App.tsx Alt+N renumbering — the flagged-as-risky shortcut shift has no keyboard test (only DOM order is asserted); one test that Alt+2 opens Profiles and Alt+3 opens Agents would pin it.
- [tests] ConfirmModal
typedreset-on-open and the delete/detail error paths (DELETE rejection → snackbar + row retained; detail-panegetProfilefailure) are untested. - [conventions] docs/web-ui.md — the Features paragraph doesn't mention profile management; one clause would do (component detail stays in web/README.md).
- [conventions] commit subject — 77 chars, over the ~72 norm; move the verb enumeration to the body.
- [conventions] ProfilesPanel.tsx — the
{/* Master list */}comment: "master-detail" is a standard pattern name, but the standalone "Master" could read as "List pane" per the project's inclusive-language convention.
Tests
63 new tests across 4 files, counts exact, and assertion quality is deliberately anti-tautological (ordering fixtures defeat both alphabetical and catalog-order coincidences; $&/$'/CRLF adversarial cases assert the actual old bugs; the byte round-trip pins ${VAR} through edit→PUT; the clone test asserts validated bytes equal the POST body; optimistic delete is proven independent of the refetch). The systematic gap is concurrency: the stale-response discard branch is never exercised anywhere, which is precisely where all the known and new race findings live — see the Important items for the two regression tests that should accompany the fixes. Secondary gaps: the CustomSelect rewrite, Alt+N mapping, ConfirmModal reset, and a few error paths.
Verification
Independent dynamic verification at this head (clean checkout):
- ✓ VERIFIED — web suite: 16 files, 240/240 passed (after working around the known npm optional-deps issue for rolldown's native binding, unrelated to the PR).
- ✓ VERIFIED —
npm run build(tsc + vite) exit 0; standalonetscexit 0; only the advisory 823 kB chunk-size notice. - ✓ VERIFIED — maintainer P1 defect chain end-to-end by code reading (early return at :333 leaves token valid → stale
setPreview→canCreateenables →createProfilepersists the stale body), plus the two additional un-flagged instances of the same root cause (open-reset effect, template-deselect branch) now in Blocking. - ✓ VERIFIED — maintainer P2 at ProfilesPanel.tsx:270-276 as described.
- ✓ VERIFIED — the
extractFrontmatterNamenit reproduced empirically (bodyname:returned when frontmatter lacks one). - ✓ Audited the remaining async paths in the diff: ProfileEditorModal's unguarded load is not a practical defect (conditional mount discards stale state); ProfilesPanel detail effect, MemoryGraphView, and useEventFollow use correct guard patterns.
Verdict
Request changes — maintainer @haofeif's CHANGES_REQUESTED stands at this exact head with the P1 data-integrity blocker unaddressed, and the fix should cover the whole token-invalidation class (the two additional preview-clear sites and the unguarded getTemplateSchema fetch), with regression tests, not just the two flagged lines. Everything else about the PR is in strong shape; once the race class is closed and the doc/CHANGELOG drift is patched, this is a clean approve.
Review findings on #692: the monotonic seq tokens guarding the debounced search and preview fetches were bumped only when a new request was issued, never when the reason for the in-flight request disappeared. Every clear path therefore left the old token valid, letting a late response re-land silently: - template switch (preview effect early return): the previous template's render re-armed Create with its body while the pane showed only 'Loading template schema...' (P1, silent wrong-document persistence) - modal close/reopen and template deselect: same class, distinct triggers - search-box clear: stale results restored under an empty box (P2) Also adds the staleness token getTemplateSchema was missing (fast A->B switch with out-of-order resolution left A's schema under B's selection). Regression tests use gated promises to genuinely reorder resolutions -- the seq-discard branch was previously never exercised by any test. All four new tests fail against the unfixed components (mutation-verified). Suite: 244/244, tsc clean, build clean.
Review items on #692: - Object/JSON textareas are controlled from the draft string: an uncontrolled defaultValue remounted EMPTY when Advanced collapsed and reopened while the draft silently persisted into the POST -- the user saw a blank field but the typed JSON still saved - extractFrontmatterName matches the frontmatter block first and scans name: within it, so a 'name:' line in the markdown body can no longer pre-fill the profile-name box (same bounding rewriteFrontmatterName already used) - CustomSelect gains its own suite (portal placement, portal-aware outside-click, flip-up/down positioning, scroll- and resize-to-close, invalid red boundary, disabled options). Writing it exposed a real defect: the resize path reused the scroll handler, whose contains() check throws on a resize event's window target, so the menu never closed on resize -- fixed with an instanceof Node guard - web/README.md (Profiles page section, supporting-components table, project tree), CHANGELOG.md Unreleased/Added, and docs/web-ui.md Features updated per the repo documentation-maintenance rule Suite: 255/255, tsc clean, build clean.
Remaining #692 review nits: - drop validateTemplateConfig + TemplateConfigValidation (defined but never referenced; the UI uses previewTemplate) and move the two-path-segment encoding comment to getTemplateSchema, the method it describes - pre-save validate transport failures fall through to the authoritative server-side validation instead of hard-blocking behind a phantom 'Validation failed'; real 4xx findings still block (both modals) - rename the 'Master list' comment to 'List pane' per the inclusive naming convention - tests: Alt+2/Alt+3 keyboard mapping pinned beyond DOM order, ConfirmModal typed-confirmation reset on reopen, DELETE failure (error snackbar + row retained), detail load failure (list survives, reselect works), and the validate transport fall-through Suite: 260/260, tsc clean, build clean.
PR #692 — reviewer reply draftsAll fixes are in three new commits (no force-push): 2806cfd (blockers), Reply 1 — haofeif's P1 thread (ProfileCreateModal.tsx:333, stale preview persisted)Fixed in 2806cfd, and you were right that the root cause was the token only Your probe scenario is now a regression test: template A's render released Reply 2 — haofeif's P2 thread (ProfilesPanel.tsx:272, stale search restored)Fixed in 2806cfd with exactly your snippet ( Reply 3 — haofeif's nit thread (extractFrontmatterName body match)Fixed in e070c3b using the bounding you suggested: match the frontmatter Reply 4 — top-level reply to haofeif's reviewThanks for the depth here — reproducing every claim in the PR body and then Follow-up commits e070c3b and 33d6477 address the rest of the review Reply 5 — top-level reply to gutosantos82's reviewThanks — the systemic framing was the right call. All three blocking sites On the important items (e070c3b): the JSON textareas are now controlled from Nits (33d6477): dead |
fanhongy
left a comment
There was a problem hiding this comment.
Summary
Reviewed PR #692 (feat/510-profiles-ui @ 33d6477) against main @ b6a0520: 18 files, +3422/−24, no Python or CI changes. Every backend route the UI calls exists at HEAD, and the UI's reading of the server contracts is accurate — /source (not the resolved GET /{name}) backs the editor, the ranked search order is never re-sorted, duplicated_in is surfaced, the {message, errors} write-rejection shape is rendered through the shared findings panel, and the _OMISSION_MESSAGE truncation contract (last-position, severity-of-omitted) is implemented correctly. Validation, staleness tokens, and the type-to-confirm delete gate are all real, not decorative. tsc --noEmit, npm test (260 tests), npm run build, and the four backend profile test modules (192 tests) are green locally.
Six findings, four of them demonstrated with runtime probes against the PR's own components: the detail pane never refreshes after an in-place edit, the header X re-opens the mid-save dismissal hole that Cancel and the backdrop were deliberately closed against, the scratch-mode document embeds an untrimmed name while the POST sends the trimmed one, and a failed profile-schema fetch leaves From-scratch mode on a permanent spinner. None is a security, data-loss, or build failure, so nothing here is P1.
Two design calls I looked at and am not flagging: the fall-through-to-write on a 5xx/transport pre-save validate failure is safe (the write route re-validates and returns a renderable 400) and is documented at both call sites; and the absence of an Authorization header is repo-wide and pre-existing, not introduced here — worth a separate issue now that write operations are on the ungated client.
Findings
P2 — The detail pane keeps showing pre-edit values after a successful save
web/src/components/ProfilesPanel.tsx:94-104 (the fetch effect), :403 (the render site), :221-229 (handleSaved)
ProfileDetail's fetch effect is keyed on [row.name]. After an in-place edit, handleSaved calls setSelected(name) (same name) and refreshCatalog(). The catalog refresh produces a new row object but an unchanged row.name, so the effect never re-runs and detail — the source of Provider, Model, Role, Tags and Capabilities — is never refetched.
Triggering scenario: select a local profile whose model is claude-sonnet-4, click Edit, change the frontmatter to model: claude-opus-5, save. The snackbar says Profile 'developer' saved, the PUT succeeded, and the detail pane still reads claude-sonnet-4 until the user selects a different profile and comes back. Verified by probe: detailFetches stays at 1 across the whole save, and the rendered pane is …Providerkiro_cliModelclaude-sonnet-4. description and source do update (they come from the refreshed catalog row), which makes the stale half more convincing, not less. Clone is unaffected — the new name changes the effect key.
Correction: give the detail fetch an explicit reload token, e.g. a reloadNonce counter bumped in handleSaved and passed as a prop into the effect's dependency array (or key={${selectedRow.name}:${reloadNonce}} on <ProfileDetail>). No existing test covers detail state after a save; profile-editor.test.tsx:84 asserts only the PUT body.
P2 — The header X is not disabled during a save, and closing mid-flight discards the write outcome silently
web/src/components/ProfileEditorModal.tsx:126, web/src/components/ProfileCreateModal.tsx:589
Both modals gate the backdrop on !saving (ProfileEditorModal.tsx:116, ProfileCreateModal.tsx:573) and disable Cancel while saving (:193, :755), with a comment at each site explaining why. The aria-label="Close" X calls onClose unconditionally, so it reopens exactly that hole — and for the editor it is worse than the comment anticipates, because ProfilesPanel.tsx:419 renders the editor as {editor && <ProfileEditorModal …>}, so onClose unmounts it.
Triggering scenario: click Save changes, then click X while the PUT is in flight. The PUT returns 400 {message: "Profile failed validation and was not written.", errors: […]}. setSaveError/setFindings land on an unmounted tree, and nothing else surfaces the failure: no snackbar, no alert. Verified by probe — with Cancel confirmed disabled === true at the same instant, clicking X unmounted the editor (Profile source textarea gone) and after the 400 resolved screen.queryAllByRole('alert') was []. The user is left believing an edit was saved that was rejected. The create modal has the same outcome by a different route: it stays mounted but renders null, so saveError is invisible and is then wiped by the reset effect on the next open.
Correction: onClick={saving ? undefined : onClose} (or disabled={saving}) on both X buttons, matching Cancel. profile-editor.test.tsx:370 already pins the backdrop path — parameterising it over backdrop / Cancel / X would pin all three.
P2 — Scratch-mode frontmatter carries the untrimmed name while the POST carries the trimmed one
web/src/components/ProfileCreateModal.tsx:496 vs :515 (and canCreate at :503-504)
handleCreate computes const name = profileName.trim() for the POST, and template mode rewrites the document with that trimmed value. buildScratchContent instead does { name: profileName, … } — untrimmed. canCreate only requires profileName.trim() !== '', so surrounding whitespace passes the client gate.
Triggering scenario: in From scratch, paste my-agent (trailing space — routine when copying a name out of a doc or terminal) and click Create. Verified by probe: the validated/POSTed document is ---\nname: "my-agent "\n---\n\n while the POST name is "my-agent". Confirmed against the real backend, validate_profile_text on that document returns exactly one finding: error | name | 'my-agent ' does not match '^[A-Za-z0-9_-]{1,64}$'. So the pre-save gate blocks the create and paints the Profile-name box red while quoting a value whose only defect is invisible — and the identical input succeeds in template mode. Were the pattern ever relaxed, the same skew would hit _validate_profile_for_write's name-identity check (api/main.py:2489-2495) instead.
Correction: use the trimmed name in buildScratchContent, e.g. { name: profileName.trim(), …scratchValues }, or hoist const name = profileName.trim() and pass it in.
P2 — A failed profile-schema fetch leaves From-scratch mode on a permanent spinner
web/src/components/ProfileCreateModal.tsx:336, rendered at :674-677
api.getProfileSchema().then(setProfileSchema).catch(() => setProfileSchema(null)) maps failure onto the same null that means "still loading", and the render is {!profileSchema ? <Loader2 …/> Loading profile schema… : …}. There is no error state and no retry. This is the opposite shape from the sibling template-schema fetch, which commit 2/4 specifically hardened to surface its error (:362-365).
Triggering scenario: GET /agents/profiles/schema returns 500 (or the request times out). Verified by probe: after switching to From scratch, profile-schema-loading is still present and screen.queryAllByRole('alert') is [] — the mode is permanently unusable with no indication of why. api.listProfileTemplates().catch(() => setTemplates([])) at :335 degrades more visibly (the select reads "No options available") but also swallows the cause. Neither path is tested.
Correction: add a schemaError state set in the catch and render it through the same amber/red banner the template path uses, keeping the spinner for the genuinely-in-flight case.
P3 — Object-typed primary fields would render blank while still saving their JSON (latent)
web/src/components/ProfileCreateModal.tsx:687 vs :726
handleScratchChange (:487-493) routes any field whose schema type === 'object' into jsonDrafts, never into scratchValues. The ADVANCED call site accounts for that (value={scratchProps[k].type === 'object' ? (jsonDrafts[k] ?? '') : scratchValues[k]}); the PRIMARY call site passes value={scratchValues[k]}. Commit e070c3b fixed exactly this bug — a blank textarea that still POSTs the typed JSON — but only at the advanced site.
Not reachable today: the six PRIMARY_FIELDS resolve against agent_profile.schema.json to name/description/provider/model (string) and tags/capabilities (array), none of them object. It returns silently the moment an object-typed field is added to PRIMARY_FIELDS or an existing primary field's schema type changes. Correction: use the advanced site's expression at both call sites.
P3 — The portaled menu declares role="listbox" but contains no options
web/src/components/CustomSelect.tsx:121 (container), :134 (items)
The portal wrapper gained role="listbox", but its children are <button> elements inside plain <div> group wrappers — no role="option", no aria-selected, and the trigger has aria-expanded without role="combobox", aria-haspopup, or aria-controls. Assistive technology announces a list box with zero items, which is worse than the previous role-less <div>. This affects every consumer of the shared component (Agents, Flows, Memory, workflow run comparison), not just the new modals.
Correction: either drop role="listbox" and keep the menu a plain container of buttons (matching the pre-PR behaviour), or complete the pattern — role="option" + aria-selected on each item, role="presentation" on the group wrappers, and role="combobox"/aria-controls on the trigger.
Validation / tests
All commands run from /tmp/cao-pr-review-awslabs-cli-agent-orchestrator-pr-692/checkout at 33d6477, darwin/arm64. The checkout was not modified (git status --porcelain empty before and after; the Vite output directory is gitignored).
| Command | Result |
|---|---|
npx tsc --noEmit (in web/) |
exit 0 |
npm test (vitest, the CI "Run tests" step) |
17 files / 260 tests passed |
npm run build |
exit 0 (pre-existing 823.71 kB chunk-size warning) |
uv run --frozen pytest test/api/test_api_profile_surface.py test/api/test_api_profiles.py test/services/test_profile_validator.py test/services/test_profile_store.py -q |
192 passed, 3 warnings |
Backend contract spot-checks (this PR changes no Python; these confirm the assumptions the UI is built on):
validate_profile_text('---\nname: "my-agent "\n---\n\n')→error | name | 'my-agent ' does not match '^[A-Za-z0-9_-]{1,64}$'(finding 3).render_template('aws/sqs-monitor', {…defaults only})→Config validation failed …\n - (root): 'profile' is a required property, which thetemplateErrorFieldsparser atProfileCreateModal.tsx:423-438matches exactly,(root)branch included.- All seven shipped
templates/aws/*/template.md.j2files carry a frontmattername:line, sorewriteFrontmatterName's no-name:no-op path is not reachable from the UI.
Findings 1–4 were each demonstrated by running the PR's own components under vitest with assertions written for the correct behaviour, so a failure means the defect is present. Probes live in a scratch copy of web/ at /tmp/probe692-web (rsync excluding node_modules, which is symlinked back) — the checkout itself was never written to. Probe outputs quoted inline above: detailFetches after save = 1; cancel disabled during save = true / editor still mounted after X = false / alerts visible anywhere = []; validated content = "---\nname: \"my-agent \"\n---\n\n" with POST name = "my-agent"; spinner still shown = true / alerts = [].
Open questions / residual risk
- CustomSelect in a real browser. The portal +
position: fixedrewrite is the widest-blast-radius change and jsdom cannot lay out, so the 9 new tests pin behaviour (portal placement, flip via mockedgetBoundingClientRect, portal-aware outside-click, scroll/resize close, Escape) but not geometry. Worth one manual pass through the Flows and Agents creation modals. Related nit, not filed as a finding: when the menu flips up,maxHeightstays atMENU_MAX_Hrather than being clamped to the space above, so on a very short viewport (< ~500 px) the top of the list would sit above the viewport edge and be unreachable. OMISSION_MESSAGEdrift.ValidationFindings.tsx:13is a hand-copiedprofile_validator._OMISSION_MESSAGE. If the Python string changes, the truncation notice silently degrades into an ordinary 100th finding row; no test crosses the language boundary.- Refresh. The catalog is fetched once on mount plus after this panel's own writes, with no manual refresh control — a
cao profile installwhile the tab is open is invisible until remount. Deliberate and commented; flagging because it compounds finding 1.
haofeif
left a comment
There was a problem hiding this comment.
Thanks for the thorough turnaround on this — the blockers were fixed as a class rather than patched at the one site I happened to reproduce, and every fix landed with a regression test that actually fails without it.
Reviewed at 33d6477eee97383dd3f93eb4399e91f8ebce15ec (merge-base b6a0520bd2cc3e1c2e7756197c84710afdc8bbb3). Three additive commits since my last review, no force-push, so the earlier history is intact.
Claims verified by execution
| Claim | Method | Result |
|---|---|---|
| 260/260, tsc + vite build clean | npm ci && npm test && npm run build |
✅ exactly 260 passed, 17 files; build exit 0 |
| P1 (stale template preview) fixed | replayed my original probe, unchanged | ✅ Create disabled, no POST (was: POSTed template A's body) |
| P2 (search clear) fixed | replayed my original probe | ✅ 6 catalog rows restored under an empty box (was: 1) |
nit (extractFrontmatterName) fixed |
my original decoy document | ✅ null (was: "decoy"); normal + CRLF still resolve real |
| "new tests fail against the unfixed components" | reverted each fix, re-ran its test | ✅ every mutant dies — see below |
| CustomSelect suite "caught a real bug" | reverted the instanceof guard |
✅ real: TypeError: Failed to execute 'contains' on 'Node': parameter 1 is not of type 'Node' |
validateTemplateConfig removal safe |
grep -rn across web/src/ |
✅ genuinely unreferenced |
Mutation results
Reverting each fix and re-running only its guard test:
M1 preview-effect early-return bump -> FAILS (guards)
M3 templateSeq staleness token -> FAILS (guards)
M4 searchSeq bump on clear -> FAILS (guards)
M5 bounded extractFrontmatterName -> FAILS (guards)
M6 JSON draft controlled value -> FAILS (guards)
resize `instanceof Node` guard -> FAILS (guards, with the TypeError above)
A correction to my own method, so the above is not over-read. My first pass mutated the open-reset invalidatePreview() alone and reported the reopen test as vacuous. That was my error: the three invalidation sites are mutually redundant by design, so removing one leaves the template-deselect branch covering the path. Removing all three together fails both your test and a stronger probe I wrote:
all 3 guards present -> create disabled: true, POSTed stale A body: no POST
remove all 3 sites -> create disabled: false, POSTed stale A body: true
So the reopen test does guard real behaviour, and the redundancy is a property of the fix rather than a gap in the test. Worth stating explicitly because a single-site mutation is a misleading way to grade this fix — as I demonstrated on myself.
On the two unrequested changes in these commits
Both were outside my original findings, so I reviewed them fresh rather than re-verifying:
- 5xx/transport fall-through on the pre-save validate (
ProfileCreateModal.tsx,ProfileEditorModal.tsx). Safe, and the justification in the comment holds:POST /agents/profilesandPUT /agents/profiles/{name}both call_validate_profile_for_writebeforewrite_profile(api/main.py:2571,:2605), whose docstring is explicit that "an invalid profile never reaches disk". A 4xx still blocks —ApiError.statusis set on every non-OK response infetchJSON— and a write rejection is surfaced through the same findings renderer viadetailMeta.errors, so nothing fails silently. Net effect is a flaky-validate call no longer hard-blocks behind a phantom "Validation failed". - JSON draft display desync. The
defaultValue→ controlledvaluechange is the right fix; an uncontrolled textarea remounted empty on Advanced collapse/reopen while the draft that actually saves persisted, which is the same silent-divergence shape as the original P1.
Verdict
Approving. All three findings from my previous review are fixed at the root, the fixes are pinned by tests that fail without them, the full suite is green on a pristine tree, and I found no new defects.
Note for the author: @gutosantos82's CHANGES_REQUESTED at the old head is a separate outstanding block and will need their re-review independently of this approval.
|
@gutosantos82 can you pls check |
Overview
Third and final PR for #510, building the web UI on top of the APIs from #575 (validation service,
/validate,/schema) and #585 (create/update/delete/source endpoints). Adds a Profiles tab where you can browse and search installed profiles, create new ones from a scaffold template or from scratch, edit and clone existing ones, and delete local-store profiles — with the backend validator wired in front of every write.AgentPanelis untouched; it remains the agent-launch picker. Closes #510 once merged.What's in it
Navigation. A Profiles tab between Home and Agents, and the Home dashboard's Profiles stat card now navigates there. This renumbers the Alt+N shortcuts for the tabs after it (one-time shift; the code comment explains the ordering rationale).
List, search, detail. Master-detail layout. The catalog is fetched once on mount — no polling. Search delegates ranking to
GET /agents/profiles/searchwith a 300 ms debounce; results render in server order (the client never re-sorts) and a monotonic token discards stale responses. The detail pane shows source, provider, model, tags, capabilities, and a warning whenduplicated_inreports the name shadowed across directories.Create. One modal, two entry points. From template: pick a scaffold template, fill a form generated from that template's own JSON-Schema, and watch a live preview rendered by
POST /templates/preview(same 300 ms debounce; Create is gated while a render is in flight, and the preview shows the exact document that will be persisted, including the frontmatter name rewrite). From scratch: the form is generated fromGET /agents/profiles/schema— primary fields visible, everything else behind an Advanced expander. Object-valued fields (mcpServers,codexConfig, …) use validated JSON editors rather than bespoke widgets. Frontmatter is emitted as JSON-valued YAML, so no YAML dependency is added.provideris a select fed by the live registry (uninstalled providers labelled but selectable, free-text fallback if the registry call fails);roleis a datalist with the built-in roles plus free entry forsettings.jsoncustom roles;modelstays free text deliberately — there is no registry of valid model IDs to validate against.Edit, clone, delete. Edit opens a raw document editor over
GET /agents/profiles/{name}/sourceand saves via PUT — deliberately not the schema form, because an edit must round-trip the exact stored bytes (env-var placeholders intact; one test pins a${VAR}surviving the full load→edit→PUT cycle). Only local-store profiles get Edit/Delete; built-in, provider, and custom profiles are read-only and offer "Clone to customise", matching the backend's write model. Delete sits behind a type-the-name-to-confirm gate (an optional, additiveconfirmationTextprop on the sharedConfirmModal; existing callers unchanged).Validation.
POST /agents/profiles/validateruns before every save, in both modals and both modes. Errors block client-side; warnings render but allow (and surface again via snackbar after the save). The findings panel renders the truncation contract from #585 precisely: at most 100 findings including one omission marker, exactly once, last, with its severity matching the omitted producer — an error-severity marker is explicitly flagged as hiding errors, and the marker text in any non-final position renders as an ordinary finding. Error findings also paint the specific form control they name (dotted path rooted at the frontmatter key,(root)required-property errors mapped by the quoted name), auto-expanding the Advanced section when the target is hidden. Clone validation runs on the exact rewritten document; one test asserts the validated bytes equal the POST body.Hardening
The branch was reviewed adversarially before opening, with empirical probes against the frontmatter helpers; everything found was fixed here rather than left for review:
rewriteFrontmatterNameuses replacement functions, not strings — a replacement string interprets$-patterns, which corrupted names containing$&/$'and mangled documents whose frontmatter legally contains such text.Tests
63 new UI tests across four files (12 panel, 25 create modal, 15 editor, 11 findings renderer); full web suite 240/240,
tscclean, production build clean. The areas #585's review focused on get the same treatment here: debounce coalescing is asserted by call count, the search-order test uses three rows whose ranked order differs from both alphabetical and catalog order so a re-sort can't pass by coincidence, and the truncation rendering is tested with full 100-finding payloads including marker-severity and marker-position cases.Not in scope (per #510)
Launching agents from the Profiles tab, workflow/team composition, profile rename semantics, and full JSON-Schema modelling of every object-valued field (JSON editors suffice). One known follow-up: making the template live preview directly editable (with dirty-state semantics so form edits don't clobber manual edits) — deferred to keep this PR reviewable; happy to file an issue.